#!/usr/bin/ruby

APP_NAME = "calllog2ical.rb"
APP_VERSION = "0.11"

# calllog2ical.rb
#
# An somewhat obnoxious, but effective script that will copy your iPhone's
# call log into a calendar you specify.
#
# It reads the existing calendar and only enters events that are new.
#
# KNOWN ISSUES:
# * The ical entry will only contain the name of the contact that called IF the
#		contact exists in your iPHONE's address book, not your Mac's address book.
# * Only works in Leopard.

# "calllog2ical.rb" is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as published 
# by the Free Software Foundation.
# 
# "calllog2ical.rb" is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.	See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with "calllog2ical.rb"; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA	 02110-1301	 USA

require 'optparse'

require 'rubygems'
require 'osx/cocoa'
require 'sqlite3'

include OSX

def verbose(str)
	NSLog(str) if $verboseMessages
end

["CalendarStore","AddressBook"].each do |framework|
  verbose("loading #{framework} framework")
  NSBundle.bundleWithPath("/System/Library/Frameworks/#{framework}.framework").load 
  ns_import(framework.to_sym)
end

CACHE_DIR = NSHomeDirectory() + '/Library/Caches/iPhoneCallLog/'
Dir.mkdir(NSHomeDirectory() + "/Library/Caches/iPhoneCallLog/") unless
	FileTest.exist?(NSHomeDirectory() + "/Library/Caches/iPhoneCallLog/")


def clean_phone_number(num)
  return num.gsub(/\D/, '')
end

module IPhoneBackup
  include OSX
  
  class DeviceInfoUnavailableError < RuntimeError; end
  
  class DeviceNoBackupsFoundError < RuntimeError; end
  
  class AddressBook
    def initialize()
        sql_data = IPhoneBackup::data_for_domain_and_path('HomeDomain','Library/AddressBook/AddressBook.sqlitedb')
        raise(RuntimeError,"There was an error reading your iPhone backup data") unless sql_data
        sql_data.writeToFile_atomically_(CACHE_DIR + 'addressbook.sqlite', true)
        @sqldb = SQLite3::Database.new(CACHE_DIR + 'addressbook.sqlite')
        ObjectSpace.define_finalizer(self,Proc.new {@sqldb.close;#File.unlink(CACHE_DIR + 'addressbook.db')
           } )
    end
    
    def contact_for_phone_number(phone_num, callerid)
 #     if callerid == 0
        verbose("Looking for phone number #{phone_num} in iPhone database")
        @sqldb.execute("SELECT value, record_id FROM ABMultiValue WHERE property = 3") do |row|
          pattern = Regexp.compile "\d*#{phone_num}$"
          thisCleanNum = clean_phone_number(row[0])
          callerid = row[1] if row[0] and thisCleanNum.match(pattern)
        end
        query = "SELECT `First`,`Last`,`Organization` FROM ABPerson WHERE ROWID = '#{callerid}'"
        results = @sqldb.execute(query)
        if results.size > 0
          row = results[0]
          return {:first_name => row[0] , :last_name => row[1] , :organization => row[2]}
        else
          return nil
        end
#      end
    end
    
  end

  def self.backup_dir()
    dirs = Dir.glob(NSHomeDirectory() + "/Library/Application Support/MobileSync/Backup/*")
    raise DeviceNoBackupsFoundError unless dirs[0]
    dirs[0]
  end

  def self.device_name()
    phoneDir = backup_dir()
    raise DeviceInfoUnavailableError unless FileTest.file? phoneDir + "/Info.plist"

    infoData = NSData.dataWithContentsOfFile_(phoneDir + "/Info.plist")
    raise DeviceInfoUnavailableError unless infoData

    infoPlist = NSPropertyListSerialization.propertyListFromData_mutabilityOption_format_errorDescription_(
      infoData, NSPropertyListImmutable, nil, nil)
    raise DeviceInfoUnavailableError unless infoPlist
  
    return infoPlist.objectForKey_("Device Name") 
  end

  def self.plist_for_backup_path(path)
    thisData = NSData.dataWithContentsOfFile_(path)
  	NSPropertyListSerialization.propertyListFromData_mutabilityOption_format_errorDescription_(
  		thisData, NSPropertyListImmutable, nil, nil)
  end

  def self.data_for_domain_and_path(domain,path)
    backup_dir = backup_dir()

    Dir.glob(backup_dir + "/*.mdbackup") do |testPath|
      plist = plist_for_backup_path(testPath)
    	this_domain = plist.objectForKey_('Domain')
    	this_path = plist.objectForKey_('Path')
    	if this_domain == domain && this_path == path
    	  return plist.objectForKey_('Data')
    	end 
    end

    Dir.glob(backup_dir + "/*.mdinfo") do |testPath|
      plist = plist_for_backup_path(testPath)
      this_domain = plist.objectForKey_('Domain')
    	this_path = plist.objectForKey_('Path')
      data_path = testPath.sub(/.mdinfo$/,'.mddata')
      if this_domain == domain && this_path == path
        return NSData.dataWithContentsOfFile(data_path) 
      end
    end
    nil
  end
end

class NSDate
	def inspect
		return description.to_s.inspect
	end
end

class Numeric
	
	def to_s_as_time
		 NSDate.dateWithTimeIntervalSince1970(self).description
	end
	
end

class CLGCall < OSX::NSObject
  
  @@address_book = ABAddressBook.sharedAddressBook
  @@iphone_address_book = IPhoneBackup::AddressBook.new
  
	attr_accessor :date, :callerID, :duration, :flags
	attr_accessor :phoneNumber
	
	  def self.phone_number_styles(n) 
	    retary = []
	    numbers =  n.gsub(/\D/, '')
	    retary << numbers.to_s
	    if numbers.size == 10
	      partitions = [numbers[0..2],numbers[3..5],numbers[6..9]]
 	      retary << "%s-%s-%s" % partitions
 	      retary << "%s %s %s" % partitions
        retary << "(%s) %s-%s" % partitions
        retary << "(%s)%s-%s" % partitions
        retary << "+1%s%s%s" % partitions
        retary << "1%s%s%s" % partitions
      end
      return retary
    end
	
		def contact
		  verbose("CLGCall#contact entered...")
		  retVal = nil
		  clean_num = clean_phone_number(@phoneNumber)
		  styles = CLGCall.phone_number_styles(clean_num)
		  return nil unless clean_num && clean_num.size > 0
		  styles.each do |phone|
		    search_element = ABPerson.searchElementForProperty_label_key_value_comparison_(
		      'Phone', nil, nil, phone, 0)
		      verbose("search element #{search_element}")
		      found_contacts = @@address_book.recordsMatchingSearchElement_(search_element)
		      if found_contacts.length > 0
		        retVal = {
		          :first_name => found_contacts[0].valueForProperty_('First'),
              :last_name => found_contacts[0].valueForProperty_('Last'),
              :organization => found_contacts[0].valueForProperty_('Organization')
              }
		        break
	        end
      end
      unless retVal
        verbose('attempting to match number against iPhone address book')
        retVal = @@iphone_address_book.contact_for_phone_number(clean_num,@callerID)
      end
			verbose("CLGCall#contact will exit, returning #{retVal}")
			return retVal
		end
		
		def isOutgoing
			flags == 5 ? true : false
		end
		
		def inspect
			"#<CLGCall:"+ ("0x%x" % [object_id]) + " "+ \
			([:date, :phoneNumber,
				:callerID,:duration,:flags].collect {|sym| sym.to_s + "=" + method(sym).call.inspect}).join(" ") + ">"
		end
end


excludeList = []
excludeIncompleteCalls = false
$verboseMessages = false
leaveDB = false
dry_run = false
forceDate = nil
oparse = OptionParser.new {|op|
	op.banner = "Usage : calllog2ical.rb [options] [CALENDARNAME]"
	op.on("-v","--verbose","Run verbosely") {
		$verboseMessages = true
	}
	op.on('-d','--dry-run',"Dry Run (don't alter the Calendar Strore)") {
	  dry_run = true
	}
	op.on("-l","--leave-db","Leave the extracted working sqlite files",
	                        "in their temporary location instead of deleting.") {
		leaveDB = true
	}
	op.on("-e NUMBER","--exclude-number NUMBER","Exclude calls from the given number") { |number|
		excludeList << number.delete("^0123456789+")
	}
	op.on("-x EXCLUDE-FILE","--exclude-file EXCLUDE-FILE",
	"Exclude calls listed in EXCLUDE-FILE.",
	"EXCLUDE-FILE should contain a whitespace-",
	"separated list of telephone numbers."
	) { |file|
		File.open(file,"r") do |fp|
			fp.read.split(/\W/).each {|number|
				excludeList << number.delete("^0123456789+")
			}
		end
	}
	op.on("-f DATE", "--force DATE","Force calendar to be updated with all call",
	                                "log events since DATE, deleting all events",
	                                "after DATE before going through the normal",
	                                "read process") {|date|
	  forceDate = NSDate.dateWithNaturalLanguageString(date)
	}
	op.on("-0","Don't copy events with zero duration.",
	"(Incomplete/Unanswered calls)") {
		excludeIncompleteCalls = true
	}
	op.on_tail("-h","-?","Show this message.") {
		puts op
		exit(0)
	}
}

destinationCalendarName = oparse.parse!(ARGV)[0] || "Call Log"

excludeList.each {|n| verbose("Excluding number: #{n}")}

calls = []
calendarStore = CalCalendarStore.defaultCalendarStore()
@selectedCalendar = calendarStore.calendars.detect{|cal| cal.title == destinationCalendarName}

if forceDate
  verbose("A force date was given, locating events to delete...")
  deletePred = CalCalendarStore.eventPredicateWithStartDate_endDate_calendars_(forceDate,NSDate.date,[@selectedCalendar])
  eventsToDrop = calendarStore.eventsWithPredicate(deletePred)
  verbose("Deleteing %i events..."%eventsToDrop.size)
  eventsToDrop.each do |event|
    calendarStore.removeEvent_span_error(event,0,nil)
  end
end

unless @selectedCalendar
	verbose("The calendar #{destinationCalendarName} does not exist.")
	exit(1) 
end

# load data from cellphone backup store

@phoneName = "Unknown Device"

begin
  @phoneName = IPhoneBackup::device_name()
rescue IPhoneBackup::DeviceInfoUnavailableError
  Nlog("The Info.plist describing device could not be found. " + 
  "Your device's name will not appear in recorded entries.")
end

verbose("Phone name is #{@phoneName}")

callSQLData = nil
verbose("Reading iPhone backup data files...")
callSQLData = IPhoneBackup::data_for_domain_and_path('HomeDomain','Library/CallHistory/call_history.db')
verbose("Call log data found, data size %i" % [callSQLData.length])
callSQLData.writeToFile_atomically_(NSHomeDirectory() + "/Library/Caches/iPhoneCallLog/call_db.sqlite3", true)

if (callSQLData) then
	verbose("Call Log found")
	Dir.mkdir(NSHomeDirectory() + "/Library/Caches/iPhoneCallLog/") unless
		FileTest.exist?(NSHomeDirectory() + "/Library/Caches/iPhoneCallLog/")

	verbose("Opening extracted databases")
	calldb = SQLite3::Database.new(NSHomeDirectory() + "/Library/Caches/iPhoneCallLog/call_db.sqlite3")
	
	verbose("Determining range of events in call log")
	call_count = calldb.execute("SELECT COUNT(*) FROM call WHERE 1")[0][0].to_i
	if call_count == 0
	  verbose("No calls are in the call history. Exiting.")
	  exit(0)
  end
	
	firstBUCallDate, lastBUCallDate = calldb.execute("SELECT MIN(date),MAX(date) FROM call WHERE 1")[0].collect{|v|v.to_i}
	verbose("First event in backup: #{firstBUCallDate.to_s_as_time}")
	verbose("Last event in backup : #{lastBUCallDate.to_s_as_time}")
	
	verbose("Discovering events already in Calendar Store.	Calendar name: #{@selectedCalendar.title}")
	eventPred = CalCalendarStore.eventPredicateWithStartDate_endDate_calendars_(
	NSDate.dateWithTimeIntervalSince1970(firstBUCallDate),NSDate.date,[@selectedCalendar])
	events = calendarStore.eventsWithPredicate(eventPred)
	verbose("Currently #{events.size} events in calendar at given range")
	lastEvent = (events.sort{|a,b| a.startDate.compare(b.startDate)}).last
	verbose("Last event in calendar store: #{lastEvent.inspect}")
	
	lastEventTime = (lastEvent != nil) ? lastEvent.startDate.timeIntervalSince1970 : 0
	verbose("Will read events from backup ocurring after #{lastEventTime.to_s_as_time}")
	
	verbose("Reading call log for new events to add")
	calldb.execute("SELECT * FROM call WHERE date > '#{lastEventTime}'") do |row|
		newCall = CLGCall.alloc.init
		newCall.date = NSDate.dateWithTimeIntervalSince1970(row[2])
		newCall.phoneNumber = row[1]
		newCall.callerID = row[5].to_i
		newCall.flags = row[4].to_i
		newCall.duration = row[3].to_i
		calls << newCall
	end
	calldb.close
else
  verbose("Failed tofind contact DB and call log. Exiting.")
  exit 1
end
verbose("Done reading events to add, total events to add: #{calls.size}")

calls.each do |call|
	next if (call.duration == 0 && excludeIncompleteCalls)
	next if (excludeList.include? call.phoneNumber)
	newEvent = CalEvent.alloc.init
	callDirection = call.isOutgoing ? "to" : "from"
	newEvent.setUrl(NSURL.alloc.initWithString("tel:"+ call.phoneNumber.to_s))
	
	contact_hash = call.contact
	if contact_hash
  	callerName = if contact_hash[:first_name] || contact_hash[:last_name]
          [contact_hash[:first_name], contact_hash[:last_name]].compact.join(' ')
        elsif contact_hash[:organization]
    	    contact_hash[:organization]
    end
  else
    callerName = "unidentifed caller"
  end
  callerName.strip!
	
#	if call.contact
#	  firstName = call.contact.valueForProperty_('First')
#	  lastName = call.contact.valueForProperty_('Last')
#	  orgName = call.contact.valueForProperty_('Organization')
#	  callerName = if firstName
#      firstName + ' ' + lastName
#    elsif orgName
#	    orgName
#    end
  puts("#{call.date} : #{call.duration.to_s.rjust(5)} : #{call.phoneNumber} => #{callerName}") 
  
	
	callType = (call.isOutgoing == false && call.duration == 0) ?	 "Incomplete Call" : "Call"
	newEvent.setTitle("#{callType} #{callDirection} #{callerName} on #{@phoneName}")
	newEvent.setStartDate(call.date)
	newEvent.setEndDate(NSDate.dateWithTimeIntervalSince1970( call.date.timeIntervalSince1970() + call.duration()))
	newEvent.setCalendar(@selectedCalendar)
	newEvent.setNotes("Length of call: " + call.duration.to_s + " seconds")
	verbose("Inserting CalEvent for phone call #{call.inspect}")
	unless dry_run
	  calendarStore.saveEvent_span_error_(newEvent, :CalSpanThisEvent, nil)
  end
  
end

verbose("Finished adding events to calendar")

unless leaveDB
  verbose("Throwing away temp files")
  File.unlink(NSHomeDirectory() + "/Library/Caches/iPhoneCallLog/call_db.sqlite3")
end

verbose("exiting now")
exit(0)